'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; import { assetsApi, commentsApi, AssetWithComments, Asset, Comment, AnnotationData, TranscodeStatus } from '@/lib/api'; import { Avatar } from '@/components/ui/avatar'; import { ShareModal } from '@/components/share/ShareModal'; import { VideoPlayer } from '@/components/video-player/VideoPlayer'; import { Tool } from '@/components/video-player/AnnotationCanvas'; import { formatTimecode } from '@/lib/format'; const API_BASE = process.env.NEXT_PUBLIC_API_URL || ''; const MAX_ANNOTATIONS = 10; const STATUS_CONFIG: Record = { PENDING_REVIEW: { label: 'Pending Review', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-pending' }, CHANGES_REQUESTED: { label: 'Changes Requested', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-changes' }, APPROVED: { label: 'Approved', colorClass: 'text-success', bgClass: 'badge-success', dotClass: 'status-dot-approved' }, REJECTED: { label: 'Rejected', colorClass: 'text-danger', bgClass: 'badge-danger', dotClass: 'status-dot-rejected' }, }; const TRANSCODE_CONFIG: Record = { PENDING: { label: 'Queued', color: '#94A3B8', bg: 'rgba(148,163,184,0.08)', spinner: false }, UPLOADING: { label: 'Uploading video…', color: '#60A5FA', bg: 'rgba(96,165,250,0.08)', spinner: true }, PROCESSING: { label: 'Transcoding…', color: '#A78BFA', bg: 'rgba(167,139,250,0.08)', spinner: true }, COMPLETED: { label: 'Ready', color: '#34D399', bg: 'rgba(52,211,153,0.08)', spinner: false }, FAILED: { label: 'Transcode failed', color: '#F87171', bg: 'rgba(248,113,113,0.08)', spinner: false }, UNSUPPORTED_CODEC: { label: 'Unsupported codec', color: '#FBBF24', bg: 'rgba(251,191,36,0.08)', spinner: false }, }; export default function ReviewPage() { const params = useParams(); const assetId = params.assetId as string; const { token, user } = useAuth(); const router = useRouter(); const [asset, setAsset] = useState(null); const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); const [currentTime, setCurrentTime] = useState(0); const [panelWidth, setPanelWidth] = useState(380); const [commentPanelCollapsed, setCommentPanelCollapsed] = useState(false); const [showApproval, setShowApproval] = useState(false); const [updatingStatus, setUpdatingStatus] = useState(false); const [newComment, setNewComment] = useState(''); const [submitting, setSubmitting] = useState(false); const [replyTo, setReplyTo] = useState(null); const [showResolved, setShowResolved] = useState(false); const [showDeleted, setShowDeleted] = useState(false); const [deletedLoaded, setDeletedLoaded] = useState(false); // true once we've fetched comments with deleted included const [showShareModal, setShowShareModal] = useState(false); // Drawing state — lifted to page level const [drawMode, setDrawMode] = useState(false); const [drawTool, setDrawTool] = useState('arrow'); const [drawColor, setDrawColor] = useState('#ef4444'); const [pendingStrokes, setPendingStrokes] = useState([]); // The comment we're annotating (null = annotating the main video, not a specific comment) const [annotatingComment, setAnnotatingComment] = useState(null); // Portrait / landscape detection const [isPortrait, setIsPortrait] = useState(false); // ── Side-by-side compare mode ──────────────────────────────────────────── const [compareMode, setCompareMode] = useState(false); const [compareAsset, setCompareAsset] = useState(null); const [showComparePicker, setShowComparePicker] = useState(false); const [projectAssets, setProjectAssets] = useState([]); const [compareMismatch, setCompareMismatch] = useState(null); const [compareComments, setCompareComments] = useState([]); const [playing, setPlaying] = useState(false); // Toggle annotation + speech bubble visibility per video in compare mode const [showMainAnnotations, setShowMainAnnotations] = useState(true); const [showCompareAnnotations, setShowCompareAnnotations] = useState(true); // Video element ref so we can seek directly from comment timestamp clicks const mainVideoRef = useRef(null); const handleCompareSelect = useCallback((compareAssetArg: Asset) => { setShowComparePicker(false); setCompareMismatch(null); const dur1 = asset?.duration ?? 0; const dur2 = compareAssetArg.duration ?? 0; const fps = asset?.fps ?? compareAssetArg.fps ?? 30; const diffFrames = Math.abs(dur1 - dur2) * fps; if (diffFrames > 5) { setCompareMismatch( `Videos differ by ${Math.round(diffFrames)} frames. Cannot compare — timing mismatch.` ); // Show mismatch banner but don't enter compare mode setCompareAsset(compareAssetArg); setCompareMode(true); return; } setCompareAsset(compareAssetArg); setCompareMode(true); // Fetch compare asset's own comments for per-video annotations if (token) { commentsApi.list(token, compareAssetArg.id).then(({ comments: cc }) => { setCompareComments(cc); }).catch(() => setCompareComments([])); } }, [asset, token]); const handleExitCompare = useCallback(() => { setCompareMode(false); setCompareAsset(null); setCompareMismatch(null); setCompareComments([]); }, []); useEffect(() => { const mq = window.matchMedia('(orientation: portrait)'); setIsPortrait(mq.matches); const handler = (e: MediaQueryListEvent) => setIsPortrait(e.matches); mq.addEventListener('change', handler); return () => mq.removeEventListener('change', handler); }, []); const isDraggingRef = useRef(false); const panelRef = useRef(null); const resizeStartRef = useRef<{ x: number; w: number } | null>(null); // Ref to capture strokes for save callback (avoids closure stale value) const pendingStrokesRef = useRef([]); const annotatingCommentRef = useRef(null); // Keep refs in sync with state useEffect(() => { pendingStrokesRef.current = pendingStrokes; }, [pendingStrokes]); useEffect(() => { annotatingCommentRef.current = annotatingComment; }, [annotatingComment]); const fps = asset?.fps ?? 30; // Derive the current user's project role and global role const currentUserRole = asset?.project.members.find(m => m.user.id === user?.id)?.role; const globalRole = user?.globalRole; const isProjectAdmin = currentUserRole === 'ADMIN'; const isProjectOwner = asset?.project.ownerId === user?.id; const isGlobalAdmin = globalRole === 'ADMIN'; // Only global ADMIN or project owner can see and restore deleted comments const canSeeDeletedComments = isGlobalAdmin || isProjectOwner; const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER'); // ── Poll for transcode progress ─────────────────────────────────────────── const isTranscoding = asset?.transcodeStatus === 'COMPLETED'; const pollRef = useRef | null>(null); useEffect(() => { if (isTranscoding) { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } return; } if (pollRef.current) return; pollRef.current = setInterval(async () => { if (!token) return; try { const { asset: updated } = await assetsApi.getStatus(token, assetId); setAsset(prev => prev ? { ...prev, ...updated } : prev); } catch {} }, 2000); return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, [token, assetId, isTranscoding]); // Load asset + comments const loadData = useCallback(async () => { if (!token) return; try { const [{ asset: a }, { comments: c }] = await Promise.all([ assetsApi.get(token, assetId), commentsApi.list(token, assetId, { includeDeleted: true }), ]); setAsset(a); setComments(c); } catch { router.push('/projects'); } finally { setLoading(false); } }, [token, assetId, router]); useEffect(() => { loadData(); }, [loadData]); // ── Panel resize ───────────────────────────────────────────────────────── const handlePointerMove = useCallback((e: PointerEvent) => { if (!isDraggingRef.current || !resizeStartRef.current) return; const dx = e.clientX - resizeStartRef.current.x; setPanelWidth(Math.max(280, Math.min(600, resizeStartRef.current.w - dx))); }, []); const handlePointerUp = useCallback(() => { isDraggingRef.current = false; resizeStartRef.current = null; document.body.style.cursor = ''; }, []); useEffect(() => { window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); return () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); }; }, [handlePointerMove, handlePointerUp]); const handleResizeStart = (e: React.PointerEvent) => { e.preventDefault(); isDraggingRef.current = true; resizeStartRef.current = { x: e.clientX, w: panelWidth }; document.body.style.cursor = 'col-resize'; }; // ── Comment actions ─────────────────────────────────────────────────────── const handleAddComment = async (content: string, timestamp?: number, annotations?: AnnotationData[]) => { if (!token || !content.trim()) return; setSubmitting(true); try { const { comment } = await commentsApi.create(token, assetId, { content: content.trim(), timestamp, annotations, parentId: replyTo?.id, }); if (replyTo) { setComments(prev => prev.map(c => c.id === replyTo.id ? { ...c, replies: [...(c.replies ?? []), comment] } : c )); } else { setComments(prev => [...prev, comment]); } setNewComment(''); setPendingStrokes([]); setReplyTo(null); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to add comment'); } finally { setSubmitting(false); } }; const handleResolve = async (commentId: string, action: 'approve' | 'reject') => { if (!token) return; try { const { comment } = await commentsApi.resolve(token, commentId, action); setComments(prev => prev.map(c => c.id === commentId ? comment : c)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to update comment'); } }; const handleRequestResolve = async (commentId: string) => { if (!token) return; try { const { comment } = await commentsApi.requestResolve(token, commentId); setComments(prev => prev.map(c => c.id === commentId ? comment : c)); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to request resolve'); } }; const handleDeleteComment = async (commentId: string) => { if (!token) return; // Soft delete — just mark hidden, owner can restore try { await commentsApi.delete(token, commentId); setComments(prev => prev.map(c => c.id === commentId ? { ...c, deleted: true } : c )); } catch { alert('Failed to hide comment'); } }; const handleRestoreComment = async (commentId: string) => { if (!token) return; try { const { comment } = await commentsApi.restoreComment(token, commentId); setComments(prev => prev.map(c => c.id === commentId ? comment : c)); } catch { alert('Failed to restore comment'); } }; // ── Annotation actions ───────────────────────────────────────────────────── // User clicks "Add annotation" on a comment — enter draw mode, annotate at current time const handleAddAnnotationClick = (comment: Comment) => { const existingCount = comment.annotations?.length ?? 0; if (existingCount >= MAX_ANNOTATIONS) { alert(`Maximum ${MAX_ANNOTATIONS} annotations per comment.`); return; } setPendingStrokes([]); setAnnotatingComment(comment); setDrawMode(true); }; // Each completed stroke is added to pendingStrokes const handleStrokeComplete = (stroke: AnnotationData) => { setPendingStrokes(prev => { const next = [...prev, stroke]; if (next.length >= MAX_ANNOTATIONS) { setDrawMode(false); } return next; }); }; // Save pending strokes as annotation on the parent comment (no separate reply) const handleSaveAnnotations = () => { const strokes = pendingStrokesRef.current; const parent = annotatingCommentRef.current; if (!token || !parent || strokes.length === 0) { setPendingStrokes([]); setDrawMode(false); setAnnotatingComment(null); return; } setSubmitting(true); setPendingStrokes([]); setDrawMode(false); setAnnotatingComment(null); commentsApi.updateAnnotations(token, parent.id, strokes).then(({ comment }) => { setComments(prev => prev.map(c => c.id === parent.id ? comment : c)); }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save annotation')).finally(() => setSubmitting(false)); }; // Discard pending strokes const handleUndoAnnotations = () => { setPendingStrokes([]); setDrawMode(false); setAnnotatingComment(null); }; // Delete a single annotation from a comment (owner only) const handleDeleteAnnotation = async (commentId: string, remainingAnnotations: AnnotationData[]) => { if (!token) return; try { const { comment } = await commentsApi.updateAnnotations(token, commentId, remainingAnnotations); setComments(prev => prev.map(c => c.id === commentId ? comment : c)); } catch { alert('Failed to delete annotation'); } }; const handleStatusUpdate = async (status: string) => { if (!token) return; setUpdatingStatus(true); try { const { asset: updated } = await assetsApi.updateStatus(token, assetId, status); setAsset(prev => prev ? { ...prev, status: updated.status } : prev); setShowApproval(false); } catch { alert('Failed to update status'); } finally { setUpdatingStatus(false); } }; const handleTimeUpdate = useCallback((time: number) => { setCurrentTime(time); }, []); const handleCommentSeek = useCallback((comment: Comment) => { const time = comment.timestamp ?? 0; setCurrentTime(time); if (mainVideoRef.current) { mainVideoRef.current.pause(); mainVideoRef.current.currentTime = time; } }, []); const status = asset?.status ?? 'PENDING_REVIEW'; const statusCfg = STATUS_CONFIG[status]; const transcodeCfg = asset ? TRANSCODE_CONFIG[asset.transcodeStatus] : null; const videoUrl = asset?.hlsPath ? `${API_BASE}/uploads${asset.hlsPath}` : asset ? `${API_BASE}/uploads/${asset.filePath}` : ''; const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]); const visibleComments = comments.filter(c => (showDeleted || !c.deleted) && (showResolved || !c.resolved) ); const deletedCount = comments.filter(c => c.deleted).length; // Seek to previous/next comment (defined here so they can reference visibleComments) const handlePrevComment = useCallback(() => { const ts = visibleComments .filter(c => c.timestamp != null) .map(c => c.timestamp as number) .sort((a, b) => b - a); const prev = ts.find(t => t < currentTime - 0.3); if (prev !== undefined) handleCommentSeek({ timestamp: prev } as Comment); }, [visibleComments, currentTime, handleCommentSeek]); const handleNextComment = useCallback(() => { const ts = visibleComments .filter(c => c.timestamp != null) .map(c => c.timestamp as number) .sort((a, b) => a - b); const next = ts.find(t => t > currentTime + 0.3); if (next !== undefined) handleCommentSeek({ timestamp: next } as Comment); }, [visibleComments, currentTime, handleCommentSeek]); // Only main comments (not replies, not deleted) have annotations that should show on the video const visibleAnnotations = visibleComments .filter(c => !c.deleted) .flatMap(c => (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 })) ); // Annotations for the compare video — independent per-video data const compareVisibleComments = compareComments.filter(c => !c.deleted && (showResolved || !c.resolved)); const compareVisibleAnnotations = compareVisibleComments .filter(c => !c.deleted) .flatMap(c => (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 })) ); if (loading) { return (
Loading review…
); } if (!asset) return null; return (
{/* ── Top bar ──────────────────────────────────────────── */}

{asset.title}

{asset.project?.name}
{/* Download */} Download
{/* Share */}
{/* Compare mode toggle */} {/* Status selector */}
{showApproval && ( <>
setShowApproval(false)} />
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => ( ))}
)}
{/* ── Compare picker modal ─────────────────────────────────────────────── */} {showComparePicker && ( <>
setShowComparePicker(false)} />

Select video to compare

{projectAssets.length === 0 ? (

No other completed videos in this project.

) : ( projectAssets.map(a => ( )) )}
)} {/* ── Body ───────────────────────────────────────────── */} {/* Landscape: side-by-side | Portrait: stacked (video top, comments bottom) */}
{/* Video area */}
{/* ── Side-by-side compare layout ───────────────────────── */} {compareMode ? (
{/* Main video + its comments */}
{/* Annotation toggle */}
{asset.title}
{/* Comments below main video — full available height */}
Comments {visibleComments.length} {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
{visibleComments.length === 0 ? (

No comments

) : ( visibleComments.map(comment => (
{comment.user?.name ?? 'Unknown'} {comment.timestamp != null && ( {formatTimecode(comment.timestamp, fps, asset?.duration ?? 0)} )}

{comment.content}

)) )}
{/* Compare video + its comments — only show when durations match */} {compareAsset && !compareMismatch && (
{/* Annotation toggle */}
{compareAsset.title}
{}} onDrawToolChange={() => {}} onDrawColorChange={() => {}} pendingStrokes={[]} onStrokeComplete={() => {}} onTimeUpdate={() => {}} onCommentClick={() => {}} isComparePlayer={true} externalCurrentTime={currentTime} externalPlaying={playing} thumbnailSrc={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`} thumbnailMimeType={compareAsset.mimeType} /> {/* Comments below compare video — full available height */}
Comments {compareVisibleComments.length}
{compareVisibleComments.length === 0 ? (

No comments

) : ( compareVisibleComments.map(comment => (
{comment.user?.name ?? 'Unknown'} {comment.timestamp != null && ( {formatTimecode(comment.timestamp, fps, asset?.duration ?? 0)} )}

{comment.content}

)) )}
)}
) : ( /* ── Normal single-video layout ─────────────────────────── */ )} {/* ── Compare mismatch warning ─────────────────────────── */} {compareMode && compareMismatch && (
{compareMismatch}
)} {/* Transcode status overlay — shown when video is not ready */} {transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
{transcodeCfg.spinner ? (
) : asset.transcodeStatus === 'FAILED' ? (
) : (
)}
{transcodeCfg.label} {asset.transcodeStatus === 'PROCESSING' && asset.transcodeProgress > 0 && ( {asset.transcodeProgress}% )}
{asset.transcodeStatus === 'PROCESSING' && (
)} {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (

{asset.transcodeError}

)} {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (

{asset.codec ? `Source codec "${asset.codec.toUpperCase()}" — will re-encode to H.264/AAC` : 'Re-encoding to browser-compatible format…'}

)} {asset.transcodeStatus === 'PROCESSING' && asset.codec && (

Converting from {asset.codec.toUpperCase()} → H.264/AAC

)} {asset.transcodeStatus === 'UPLOADING' && (

Video uploaded — queued for processing

)}
)} {/* Keyboard shortcuts */} {!compareMode && (
Space play/pause ±1 frame ⇧←⇧→ ±1s C draw mode Esc exit draw {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
)}
{/* Resize handle — visible grip bar with 3-dot pattern, wider hit area */} {!isPortrait && !compareMode && !commentPanelCollapsed && (
{/* Invisible wide hit area (wider than visual) */}
{/* Visual grip bar */}
{[0, 1, 2].map(i => (
))}
{/* Highlight on drag */}
)} {/* Floating expand button when panel is collapsed */} {!isPortrait && !compareMode && commentPanelCollapsed && ( )} {/* ── Comment panel — hidden in compare mode (comments are below each video) ── */} {!compareMode && (
{/* Panel header */}

Comments

{comments.length} {comments.length !== visibleComments.length && ( / {visibleComments.length} )}
{formatTimecode(currentTime, fps, asset?.duration ?? 0)} {canSeeDeletedComments && deletedCount > 0 && ( )} {compareMode && ( Compare mode )}
{/* Drawing mode banner */} {drawMode && (
{annotatingComment ? `Drawing annotation on "${annotatingComment.user?.name}" — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes` : `Drawing on video — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`}
)} {/* Comment list */}
{visibleComments.length === 0 ? (

No comments yet

Add a comment below or click Add annotation on an existing comment

) : (
{visibleComments.map(comment => ( { setReplyTo(comment); }} onResolve={(action) => handleResolve(comment.id, action)} onRequestResolve={() => handleRequestResolve(comment.id)} onDeleteSelf={() => handleDeleteComment(comment.id)} onDelete={(id) => handleDeleteComment(id)} onAddAnnotation={() => handleAddAnnotationClick(comment)} onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)} onRestore={handleRestoreComment} /> ))}
)}
{/* New comment / reply input */}
{replyTo && (
Replying to {replyTo.user?.name}
)} {/* Pending strokes indicator */} {pendingStrokes.length > 0 && (
{pendingStrokes.length} stroke{pendingStrokes.length !== 1 ? 's' : ''} ready {annotatingComment ? ` → annotation on "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
)}
{ e.preventDefault(); if (newComment.trim() || pendingStrokes.length > 0) { handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined); } }} className="flex gap-2" >